import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';

import 'image_provider.dart';

enum OrderOptionType { createDate, updateDate }

/// The abstraction of albums and folders.
/// It represent a bucket in the `MediaStore` on Android,
/// and the `PHAssetCollection` object on iOS/macOS.
class AssetPathEntity {
  AssetPathEntity({
    @required this.id,
    @required this.name,
    this.assetCount = 0,
    this.albumType = 1,
    this.lastModified,
    this.type = RequestType.common,
    this.isAll = false,
    FilterOptionGroup filterOption,
  }) : filterOption = filterOption ??= FilterOptionGroup();

  /// The ID of the album (asset collection).
  ///  * Android: `MediaStore.Images.Media.BUCKET_ID`.
  ///  * iOS/macOS: localIndentifier.
  final String id;

  /// The name of the album.
  ///  * Android: Path name.
  ///  * iOS/macOS: Album/Folder name.
  final String name;

  /// Total assets count of the album.
  final int assetCount;

  /// The type of the album.
  ///  * Android: Always be 1.
  ///  * iOS: 1 - Album, 2 - Folder.
  final int albumType;

  /// The latest modification date of the album.
  ///
  /// This field will only be included when
  /// [FilterOptionGroup.containsPathModified] is true.
  final DateTime lastModified;

  /// The value used internally by the user.
  /// Used to indicate the value that should be available inside the path.
  /// The [RequestType] of the album.
  ///
  /// this value is determined as final when user construct the album.
  final RequestType type;

  /// Whether the album contains all assets.
  ///
  /// An album includes all assets is the default album in general.
  final bool isAll;

  /// The collection of filter options of the album.
  final FilterOptionGroup filterOption;

  AssetPathEntity copyWith({
    String id,
    String name,
    int assetCount,
    int albumType = 1,
    DateTime lastModified,
    RequestType type,
    bool isAll,
    FilterOptionGroup filterOption,
  }) {
    return AssetPathEntity(
      id: id ?? this.id,
      name: name ?? this.name,
      assetCount: assetCount ?? this.assetCount,
      albumType: albumType ?? this.albumType,
      lastModified: lastModified ?? this.lastModified,
      type: type ?? this.type,
      isAll: isAll ?? this.isAll,
      filterOption: filterOption ?? this.filterOption,
    );
  }

  @override
  bool operator ==(Object other) {
    if (other is! AssetPathEntity) {
      return false;
    }
    var t = other as AssetPathEntity;
    return id == t.id &&
        name == t.name &&
        assetCount == t.assetCount &&
        albumType == t.albumType &&
        type == t.type &&
        lastModified == t.lastModified &&
        isAll == t.isAll;
  }

  @override
  int get hashCode =>
      hashValues(id, name, assetCount, albumType, type, lastModified, isAll);

  @override
  String toString() {
    return 'AssetPathEntity(id: $id, name: $name, assetCount: $assetCount)';
  }
}

/// The abstraction of assets (images/videos/audios).
/// It represents a series of fields `MediaStore` on Android
/// and the `PHAsset` object on iOS/macOS.
class AssetEntity {
  const AssetEntity({
    @required this.id,
    @required this.typeInt,
    @required this.width,
    @required this.height,
    this.duration = 0,
    this.orientation = 0,
    this.isFavorite = false,
    this.title,
    this.createDateSecond,
    this.modifiedDateSecond,
    this.relativePath,
    double latitude,
    double longitude,
    this.mimeType,
    this.subtype = 0,
  })  : _latitude = latitude,
        _longitude = longitude;

  /// The ID of the asset.
  ///  * Android: `_id` column in `MediaStore` database.
  ///  * iOS/macOS: `localIndentifier`.
  final String id;

  /// The title field of the asset.
  ///  * Android: `MediaStore.MediaColumns.DISPLAY_NAME`.
  ///  * iOS/macOS: `PHAssetResource.filename`.
  ///
  /// This field is nullable on iOS.
  /// If you need to obtain it, set [FilterOption.needTitle] to `true`
  /// or use the async getter [titleAsync].
  final String title;

  /// {@macro photo_manager.AssetType}
  AssetType get type => AssetType.values[typeInt];

  /// The subtype of the asset.
  ///
  /// * Android: Always 0.
  /// * iOS/macOS: https://developer.apple.com/documentation/photokit/phassetmediasubtype
  final int subtype;

  /// Whether the asset is a live photo. Only valid on iOS/macOS.
  bool get isLivePhoto => subtype == 8;

  /// The type value of the [type].
  final int typeInt;

  /// The duration of the asset, but in different units.
  ///  * [AssetType.audio]: Milliseconds.
  ///  * [AssetType.video]: Seconds.
  ///  * [AssetType.image] and [AssetType.other]: Always 0.
  ///
  /// See also:
  ///  * [videoDuration] which is a duration getter for videos.
  final int duration;

  /// The width of the asset.
  ///
  /// This field could be 0 in cases that EXIF info is failed to parse.
  final int width;

  /// The height of the asset.
  ///
  /// This field could be 0 in cases that EXIF info is failed to parse.
  final int height;

  bool get _isLandscape => orientation == 90 || orientation == 270;

  int get orientatedWidth => _isLandscape ? height : width;

  int get orientatedHeight => _isLandscape ? width : height;

  Size get orientatedSize => _isLandscape ? size.flipped : size;

  /// Latitude value of the location when shooting.
  ///  * Android: `MediaStore.Images.ImageColumns.LATITUDE`.
  ///  * iOS/macOS: `PHAsset.location.coordinate.latitude`.
  ///
  /// It's always null when the device is Android 10 or above.
  ///
  /// See also:
  ///  * https://developer.android.com/reference/android/provider/MediaStore.Images.ImageColumns#LATITUDE
  ///  * https://developer.apple.com/documentation/corelocation/cllocation?language=objc#declaration
  double get latitude => _latitude;
  final double _latitude;

  /// Latitude value of the location when shooting.
  ///  * Android: `MediaStore.Images.ImageColumns.LONGITUDE`.
  ///  * iOS/macOS: `PHAsset.location.coordinate.longitude`.
  ///
  /// It's always null when the device is Android 10 or above.
  ///
  /// See also:
  ///  * https://developer.android.com/reference/android/provider/MediaStore.Images.ImageColumns#LATITUDE
  ///  * https://developer.apple.com/documentation/corelocation/cllocation?language=objc#declaration
  double get longitude => _longitude;
  final double _longitude;

  /// The video duration in seconds.
  ///
  /// This getter will return [Duration.zero] if the asset if not video.
  ///
  /// See also:
  ///  * [duration] which is the duration of the asset, but in different units.
  Duration get videoDuration => Duration(seconds: duration);

  /// The [Size] for the asset.
  Size get size => Size(width.toDouble(), height.toDouble());

  /// The create time in unix timestamp of the asset.
  final int createDateSecond;

  /// The create time of the asset in [DateTime].
  DateTime get createDateTime {
    final int value = createDateSecond ?? 0;
    return DateTime.fromMillisecondsSinceEpoch(value * 1000);
  }

  /// The modified time in unix timestamp of the asset.
  final int modifiedDateSecond;

  /// The modified time of the asset in [DateTime].
  DateTime get modifiedDateTime {
    final int value = modifiedDateSecond ?? 0;
    return DateTime.fromMillisecondsSinceEpoch(value * 1000);
  }

  /// The orientation of the asset.
  ///  * Android: `MediaStore.MediaColumns.ORIENTATION`,
  ///    could be 0, 90, 180, 270.
  ///  * iOS/macOS: Always 0.
  ///
  /// See also:
  ///  * https://developer.android.com/reference/android/provider/MediaStore.MediaColumns#ORIENTATION
  final int orientation;

  /// Whether the asset is favorited on the device.
  ///  * Android: Always false.
  ///  * iOS/macOS: `PHAsset.isFavorite`.
  ///
  /// See also:
  ///  * [IosEditor.favoriteAsset] to update the favorite status.
  final bool isFavorite;

  /// The relative path abstraction of the asset.
  ///  * Android 10 and above: `MediaStore.MediaColumns.RELATIVE_PATH`.
  ///  * Android 9 and below: The parent path of `MediaStore.MediaColumns.DATA`.
  ///  * iOS/macOS: Always null.
  final String relativePath;

  /// The mime type of the asset.
  ///  * Android: `MediaStore.MediaColumns.MIME_TYPE`.
  ///  * iOS/macOS: Always null. Use the async getter [mimeTypeAsync] instead.
  ///
  /// See also:
  ///  * [mimeTypeAsync] which is the asynchronized getter of the MIME type.
  ///  * https://developer.android.com/reference/android/provider/MediaStore.MediaColumns#MIME_TYPE
  final String mimeType;

  AssetEntity copyWith({
    String id,
    int typeInt,
    int width,
    int height,
    int duration,
    int orientation,
    bool isFavorite,
    String title,
    int createDateSecond,
    int modifiedDateSecond,
    String relativePath,
    double latitude,
    double longitude,
    String mimeType,
    int subtype,
  }) {
    return AssetEntity(
      id: id ?? this.id,
      typeInt: typeInt ?? this.typeInt,
      width: width ?? this.width,
      height: height ?? this.height,
      duration: duration ?? this.duration,
      orientation: orientation ?? this.orientation,
      isFavorite: isFavorite ?? this.isFavorite,
      title: title ?? this.title,
      createDateSecond: createDateSecond ?? this.createDateSecond,
      modifiedDateSecond: modifiedDateSecond ?? this.modifiedDateSecond,
      relativePath: relativePath ?? this.relativePath,
      latitude: latitude ?? this.latitude,
      longitude: longitude ?? this.longitude,
      mimeType: mimeType ?? this.mimeType,
      subtype: subtype ?? this.subtype,
    );
  }

  static AssetEntity fromJson(Map map) {
    return AssetEntity(
      id: map["id"],
      typeInt: map["type"],
      width: map["width"],
      height: map["height"],
      duration: map["duration"],
      isFavorite: map["favorite"],
      title: map["title"],
      createDateSecond: map["createDt"],
      modifiedDateSecond: map["modifiedDt"],
      latitude: map["lat"],
      longitude: map["lng"],
      subtype: map["subtype"],
    );
  }

  @override
  int get hashCode => hashValues(id, isFavorite);

  @override
  bool operator ==(Object other) {
    if (other is! AssetEntity) {
      return false;
    }
    var t = other as AssetEntity;
    return id == t.id && isFavorite == t.isFavorite;
  }

  @override
  String toString() => 'AssetEntity(id: $id , type: $type)';
}

/// Longitude and latitude.
class LatLng {
  const LatLng({this.latitude, this.longitude});

  final double latitude;
  final double longitude;

  @override
  int get hashCode => hashValues(latitude, longitude);

  @override
  bool operator ==(Object other) {
    if (other is! AssetEntity) {
      return false;
    }
    var t = other as AssetEntity;
    return latitude == t.latitude && longitude == t.longitude;
  }
}

class RequestType {
  const RequestType(this.value);

  final int value;

  static const int _imageValue = 1;
  static const int _videoValue = 1 << 1;
  static const int _audioValue = 1 << 2;

  static const RequestType all = RequestType(
    _imageValue | _videoValue | _audioValue,
  );
  static const RequestType common = RequestType(_imageValue | _videoValue);
  static const RequestType image = RequestType(_imageValue);
  static const RequestType video = RequestType(_videoValue);
  static const RequestType audio = RequestType(_audioValue);

  bool containsImage() => value & _imageValue == _imageValue;

  bool containsVideo() => value & _videoValue == _videoValue;

  bool containsAudio() => value & _audioValue == _audioValue;

  bool containsType(RequestType type) => value & type.value == type.value;

  RequestType operator +(RequestType type) => this | type;

  RequestType operator -(RequestType type) => this ^ type;

  RequestType operator |(RequestType type) {
    return RequestType(value | type.value);
  }

  RequestType operator ^(RequestType type) {
    return RequestType(value ^ type.value);
  }

  RequestType operator >>(int bit) {
    return RequestType(value >> bit);
  }

  RequestType operator <<(int bit) {
    return RequestType(value << bit);
  }

  @override
  bool operator ==(Object other) =>
      other is RequestType && value == other.value;

  @override
  int get hashCode => value;

  @override
  String toString() => '$runtimeType($value)';
}

class SizeConstraint {
  const SizeConstraint({
    this.minWidth = 0,
    this.maxWidth = 100000,
    this.minHeight = 0,
    this.maxHeight = 100000,
    this.ignoreSize = false,
  });

  final int minWidth;
  final int maxWidth;
  final int minHeight;
  final int maxHeight;

  /// When set to true, all constraints are ignored
  /// and all sizes of images are displayed.
  final bool ignoreSize;

  SizeConstraint copyWith({
    int minWidth,
    int maxWidth,
    int minHeight,
    int maxHeight,
    bool ignoreSize,
  }) {
    minWidth ??= this.minWidth;
    maxWidth ??= this.maxHeight;
    minHeight ??= this.minHeight;
    maxHeight ??= this.maxHeight;
    ignoreSize ??= this.ignoreSize;

    return SizeConstraint(
      minWidth: minWidth,
      maxWidth: maxWidth,
      minHeight: minHeight,
      maxHeight: maxHeight,
      ignoreSize: ignoreSize,
    );
  }

  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'minWidth': minWidth,
      'maxWidth': maxWidth,
      'minHeight': minHeight,
      'maxHeight': maxHeight,
      'ignoreSize': ignoreSize,
    };
  }

  @override
  bool operator ==(Object other) {
    return other is SizeConstraint &&
        minWidth == other.minWidth &&
        maxWidth == other.maxWidth &&
        minHeight == other.minHeight &&
        maxHeight == other.maxHeight &&
        ignoreSize == other.ignoreSize;
  }

  @override
  int get hashCode =>
      hashValues(minWidth, maxWidth, minHeight, maxHeight, ignoreSize);
}

class DurationConstraint {
  const DurationConstraint({
    this.min = Duration.zero,
    this.max = const Duration(days: 1),
    this.allowNullable = false,
  });

  final Duration min;
  final Duration max;

  /// Whether `null` or `nil` duration is allowed when obtaining.
  ///
  /// Be aware, when it's true, the constraint with [min] and [max]
  /// become optional conditions.
  final bool allowNullable;

  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'min': min.inMilliseconds,
      'max': max.inMilliseconds,
      'allowNullable': allowNullable,
    };
  }

  @override
  bool operator ==(Object other) {
    return other is DurationConstraint &&
        min == other.min &&
        max == other.max &&
        allowNullable == other.allowNullable;
  }

  @override
  int get hashCode => hashValues(min, max, allowNullable);
}

class FilterOption {
  const FilterOption({
    this.needTitle = false,
    this.sizeConstraint = const SizeConstraint(),
    this.durationConstraint = const DurationConstraint(),
  });

  /// This property affects performance on iOS.
  ///
  /// If not needed, please pass false, default is false.
  final bool needTitle;

  /// See [SizeConstraint]
  final SizeConstraint sizeConstraint;

  /// See [DurationConstraint], ignore in [AssetType.image].
  final DurationConstraint durationConstraint;

  /// Create a new [FilterOption] with specific properties merging.
  FilterOption copyWith({
    bool needTitle,
    SizeConstraint sizeConstraint,
    DurationConstraint durationConstraint,
  }) {
    return FilterOption(
      needTitle: needTitle ?? this.needTitle,
      sizeConstraint: sizeConstraint ?? this.sizeConstraint,
      durationConstraint: durationConstraint ?? this.durationConstraint,
    );
  }

  /// Merge a [FilterOption] into another.
  FilterOption merge(FilterOption other) {
    return FilterOption(
      needTitle: other.needTitle,
      sizeConstraint: other.sizeConstraint,
      durationConstraint: other.durationConstraint,
    );
  }

  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'title': needTitle,
      'size': sizeConstraint.toMap(),
      'duration': durationConstraint.toMap(),
    };
  }

  @override
  String toString() {
    return const JsonEncoder.withIndent('  ').convert(toMap());
  }

  @override
  bool operator ==(Object other) {
    return other is FilterOption &&
        needTitle == other.needTitle &&
        sizeConstraint == other.sizeConstraint &&
        durationConstraint == other.durationConstraint;
  }

  @override
  int get hashCode => hashValues(needTitle, sizeConstraint, durationConstraint);
}

class DateTimeCond {
  const DateTimeCond({
    @required this.min,
    @required this.max,
    this.ignore = false,
  });

  factory DateTimeCond.def() {
    return DateTimeCond(min: zero, max: DateTime.now());
  }

  final DateTime min;
  final DateTime max;
  final bool ignore;

  static final DateTime zero = DateTime.fromMillisecondsSinceEpoch(0);

  DateTimeCond copyWith({
    DateTime min,
    DateTime max,
    bool ignore,
  }) {
    return DateTimeCond(
      min: min ?? this.min,
      max: max ?? this.max,
      ignore: ignore ?? this.ignore,
    );
  }

  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'min': min.millisecondsSinceEpoch,
      'max': max.millisecondsSinceEpoch,
      'ignore': ignore,
    };
  }

  @override
  bool operator ==(Object other) {
    return other is DateTimeCond &&
        min == other.min &&
        max == other.max &&
        ignore == other.ignore;
  }

  @override
  int get hashCode => hashValues(min, max, ignore);
}

@immutable
class OrderOption {
  const OrderOption({
    this.type = OrderOptionType.createDate,
    this.asc = false,
  });

  final OrderOptionType type;
  final bool asc;

  OrderOption copyWith({OrderOptionType type, bool asc}) {
    return OrderOption(
      asc: asc ?? this.asc,
      type: type ?? this.type,
    );
  }

  Map<String, dynamic> toMap() {
    return <String, dynamic>{'type': type.index, 'asc': asc};
  }

  @override
  bool operator ==(Object other) {
    return other is OrderOption && type == other.type && asc == other.asc;
  }

  @override
  int get hashCode => hashValues(type, asc);
}

class FilterOptionGroup {
  /// Construct a default options group.
  FilterOptionGroup({
    FilterOption imageOption = const FilterOption(),
    FilterOption videoOption = const FilterOption(),
    FilterOption audioOption = const FilterOption(),
    this.containsEmptyAlbum = false,
    this.containsPathModified = false,
    this.containsLivePhotos = true,
    this.onlyLivePhotos = false,
    DateTimeCond createTimeCond,
    DateTimeCond updateTimeCond,
    List<OrderOption> orders = const <OrderOption>[],
  }) {
    _map[AssetType.image] = imageOption;
    _map[AssetType.video] = videoOption;
    _map[AssetType.audio] = audioOption;
    this.createTimeCond = createTimeCond ?? this.createTimeCond;
    this.updateTimeCond = updateTimeCond ?? this.updateTimeCond;
    this.orders.addAll(orders);
  }

  /// Construct an empty options group.
  FilterOptionGroup.empty();

  final Map<AssetType, FilterOption> _map = <AssetType, FilterOption>{};

  /// Get the [FilterOption] according the specfic [AssetType].
  FilterOption getOption(AssetType type) => _map[type];

  /// Set the [FilterOption] according the specfic [AssetType].
  void setOption(AssetType type, FilterOption option) {
    _map[type] = option;
  }

  /// Whether to obtain empty albums.
  bool containsEmptyAlbum = false;

  /// Whether the [AssetPathEntity]s will return with modified time.
  ///
  /// This option is performance-consuming. Use with cautius.
  ///
  /// See also:
  ///  * [AssetPathEntity.lastModified].
  bool containsPathModified = false;

  /// Whether to obtain live photos.
  ///
  /// This option only takes effects on iOS.
  bool containsLivePhotos = true;

  /// Whether to obtain only live photos.
  ///
  /// This option only takes effects on iOS and when the request type is image.
  bool onlyLivePhotos = false;

  DateTimeCond createTimeCond = DateTimeCond.def();
  DateTimeCond updateTimeCond = DateTimeCond.def().copyWith(ignore: true);

  final List<OrderOption> orders = <OrderOption>[];

  void addOrderOption(OrderOption option) {
    orders.add(option);
  }

  void merge(FilterOptionGroup other) {
    for (final AssetType type in _map.keys) {
      _map[type] = _map[type].merge(other.getOption(type));
    }
    containsEmptyAlbum = other.containsEmptyAlbum;
    containsPathModified = other.containsPathModified;
    containsLivePhotos = other.containsLivePhotos;
    onlyLivePhotos = other.onlyLivePhotos;
    createTimeCond = other.createTimeCond;
    updateTimeCond = other.updateTimeCond;
    orders
      ..clear()
      ..addAll(other.orders);
  }

  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      if (_map.containsKey(AssetType.image))
        'image': getOption(AssetType.image).toMap(),
      if (_map.containsKey(AssetType.video))
        'video': getOption(AssetType.video).toMap(),
      if (_map.containsKey(AssetType.audio))
        'audio': getOption(AssetType.audio).toMap(),
      'containsEmptyAlbum': containsEmptyAlbum,
      'containsPathModified': containsPathModified,
      'containsLivePhotos': containsLivePhotos,
      'onlyLivePhotos': onlyLivePhotos,
      'createDate': createTimeCond.toMap(),
      'updateDate': updateTimeCond.toMap(),
      'orders': orders.map((OrderOption e) => e.toMap()).toList(),
    };
  }

  FilterOptionGroup copyWith({
    FilterOption imageOption,
    FilterOption videoOption,
    FilterOption audioOption,
    bool containsEmptyAlbum,
    bool containsPathModified,
    bool containsLivePhotos,
    bool onlyLivePhotos,
    DateTimeCond createTimeCond,
    DateTimeCond updateTimeCond,
    List<OrderOption> orders,
  }) {
    imageOption ??= _map[AssetType.image];
    videoOption ??= _map[AssetType.video];
    audioOption ??= _map[AssetType.audio];
    containsEmptyAlbum ??= this.containsEmptyAlbum;
    containsPathModified ??= this.containsPathModified;
    containsLivePhotos ??= this.containsLivePhotos;
    onlyLivePhotos ??= this.onlyLivePhotos;
    createTimeCond ??= this.createTimeCond;
    updateTimeCond ??= this.updateTimeCond;
    orders ??= this.orders;

    final FilterOptionGroup result = FilterOptionGroup()
      ..setOption(AssetType.image, imageOption)
      ..setOption(AssetType.video, videoOption)
      ..setOption(AssetType.audio, audioOption)
      ..containsEmptyAlbum = containsEmptyAlbum
      ..containsPathModified = containsPathModified
      ..containsLivePhotos = containsLivePhotos
      ..onlyLivePhotos = onlyLivePhotos
      ..createTimeCond = createTimeCond
      ..updateTimeCond = updateTimeCond
      ..orders.addAll(orders);

    return result;
  }

  @override
  String toString() {
    return const JsonEncoder.withIndent('  ').convert(toMap());
  }
}
